﻿'This file is part of WSUS Helper <http://wsushelper.codeplex.com/>.

'WSUS Helper is free software: you can redistribute it and/or modify
'it under the terms of the GNU General Public License as published by
'the Free Software Foundation, either version 2 of the License, or
'(at your option) any later version.

'WSUS Helper is distributed in the hope that it will be useful,
'but WITHOUT ANY WARRANTY; without even the implied warranty of
'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
'GNU General Public License for more details.

'You should have received a copy of the GNU General Public License
'along with WSUS Helper.  If not, see <http://www.gnu.org/licenses/>.

Imports Microsoft.Win32
Imports System.Net

Public Class WSUSWrapper

    Private sHostName As String
    Private bSSL As Boolean
    Private iPortNumber As Integer
    Private sDomRoot As String
    Private bReadyToConnect As Boolean

    'Flag to indicate the user has aborted the current operation.
    Private bStop As Boolean

    ''' <summary>
    ''' Occurs when a message or error needs to be logged.
    ''' </summary>
    ''' <param name="sEntry">Text of the log entry.</param>
    ''' <remarks></remarks>
    Public Event OnLogMessage(ByVal sEntry As String)

    ''' <summary>
    ''' Occurs when a potentially long procedure is about to begin tracking progress.
    ''' </summary>
    ''' <param name="iValue">Maximum number of steps to track.</param>
    ''' <remarks></remarks>
    Public Event OnProgressStart(ByVal iValue As Integer)

    ''' <summary>
    ''' Occurs when a procedure that is tracking progress has completed a single step.
    ''' </summary>
    ''' <remarks></remarks>
    Public Event OnProgressPerformStep()

    ''' <summary>
    ''' Occurs when a procedure that is tracking progress has finished.
    ''' </summary>
    ''' <remarks></remarks>
    Public Event OnProgressFinish()


    ''' <summary>
    ''' Hostname of the WSUS server.
    ''' </summary>
    ''' <value></value>
    ''' <returns></returns>
    ''' <remarks></remarks>
    Public Property HostName As String
        Get
            Return sHostName
        End Get
        Set(ByVal value As String)
            sHostName = value.Trim
            CheckConnectionDetails()
        End Set
    End Property

    ''' <summary>
    ''' Whether SSL is used for communication with the server.
    ''' </summary>
    ''' <value></value>
    ''' <returns></returns>
    ''' <remarks></remarks>
    Public Property SSL As Boolean
        Get
            Return bSSL
        End Get
        Set(ByVal value As Boolean)
            bSSL = value
        End Set
    End Property

    ''' <summary>
    ''' TCP port number the WSUS server is running on.
    ''' </summary>
    ''' <value></value>
    ''' <returns></returns>
    ''' <remarks></remarks>
    Public Property PortNumber As Integer
        Get
            Return iPortNumber
        End Get
        Set(ByVal value As Integer)
            iPortNumber = value
            CheckConnectionDetails()
        End Set
    End Property

    ''' <summary>
    ''' LDAP root of AD to search (e.g. "domain.internal" or "server.domain.internal/OU=Clients").
    ''' </summary>
    ''' <value></value>
    ''' <returns></returns>
    ''' <remarks></remarks>
    Public Property DirectoryRoot As String
        Get
            Return sDomRoot
        End Get
        Set(ByVal value As String)
            sDomRoot = value
        End Set
    End Property

    ''' <summary>
    ''' The WSUS server this wrapper communicates with.
    ''' </summary>
    ''' <value></value>
    ''' <returns></returns>
    ''' <remarks></remarks>
    Public ReadOnly Property Server As IUpdateServer
        Get
            Return AdminProxy.GetUpdateServer(sHostName, bSSL, iPortNumber)
        End Get
    End Property

    ''' <summary>
    ''' Indicates whether the connection details are in the correct format and ready to connect to a WSUS server.
    ''' </summary>
    ''' <value></value>
    ''' <returns></returns>
    ''' <remarks></remarks>
    Public ReadOnly Property ReadyToConnect
        Get
            Return bReadyToConnect
        End Get
    End Property

    ''' <summary>
    ''' Whether verbose log messages will be generated.
    ''' </summary>
    ''' <remarks></remarks>
    Public VerboseLogging As Boolean

    ''' <summary>
    ''' Check if the connection details are in the correct format
    ''' </summary>
    ''' <remarks></remarks>
    Private Sub CheckConnectionDetails()
        bReadyToConnect = (sHostName <> "") And (PortNumber > 0) And (PortNumber < 65535)
    End Sub


    ''' <summary>
    ''' Stop the current operation.
    ''' </summary>
    ''' <remarks></remarks>
    Public Sub StopOperation()

        'Log the stop request if it it the first one received since the operation began.
        If Not bStop Then LogThis("Operation stopped by user.")
        'Flag the stop request
        bStop = True

    End Sub

    ''' <summary>
    ''' Start tracking progress of a potentially long operation.
    ''' </summary>
    ''' <param name="iValue">Maximum number of steps to track.</param>
    ''' <remarks></remarks>
    Private Sub StartProgress(ByVal iValue As Integer)
        bStop = False
        RaiseEvent OnProgressStart(iValue)
    End Sub

    ''' <summary>
    ''' Perform a single step of progress tracking and determine whether it is OK to continue.
    ''' </summary>
    ''' <param name="sVerboseMessage">(Optional) An additional message to be logged if verbose logging is enabled.</param>
    ''' <returns></returns>
    ''' <remarks></remarks>
    Private Function PerformStepOK(Optional ByVal sVerboseMessage As String = "") As Boolean
        RaiseEvent OnProgressPerformStep()

        'TODO: Add verbose logging to operation loops

        'If verbose logging is enabled
        If VerboseLogging Then
            'Log any additional message that was logged for this step.
            If sVerboseMessage <> "" Then LogThis(sVerboseMessage)
        End If

        'If user has requested an immediate stop, indicate it is not OK to continue
        Return Not bStop
    End Function

    ''' <summary>
    ''' Finish tracking progress of a long operation.
    ''' </summary>
    ''' <remarks></remarks>
    Private Sub EndProgress()
        bStop = False
        RaiseEvent OnProgressFinish()
    End Sub

    ''' <summary>
    ''' Creates a new wrapper that connects to either the defined infrastructure WSUS server, or the WSUS server running on localhost.
    ''' </summary>
    ''' <param name="bAutoDetect">(Optional) Whether to try to detect an infrastructure server.</param>
    ''' <remarks></remarks>
    Public Sub New(Optional ByVal bAutoDetect As Boolean = False)

        'Are default values required?
        Dim bDefaults As Boolean = Not bAutoDetect

        If bAutoDetect Then bDefaults = Not GetWSUSServerFromRegistry()

        'Load defaults if needed
        If bDefaults Then
            sHostName = "localhost"
            bSSL = False
            iPortNumber = 80
        End If

    End Sub

    ''' <summary>
    ''' Creates a new wrapper that connects to the specified WSUS server.
    ''' </summary>
    ''' <param name="sHostName">Host name of the remote WSUS server.</param>
    ''' <param name="bSSL">Whether to use SSL for this connection.</param>
    ''' <param name="iPortNumber">Port number of the remote WSUS server.</param>
    ''' <remarks></remarks>
    Public Sub New(ByVal sHostName As String, Optional ByVal bSSL As Boolean = False, Optional ByVal iPortNumber As Integer = 80)
        Me.sHostName = sHostName
        Me.bSSL = bSSL
        Me.iPortNumber = iPortNumber
    End Sub

    ''' <summary>
    ''' Send a message to the defined log receiver.
    ''' </summary>
    ''' <param name="sMessage">Message to log.</param>
    ''' <remarks></remarks>
    Private Sub LogThis(ByVal sMessage As String)
        RaiseEvent OnLogMessage(sMessage)
    End Sub

    ''' <summary>
    ''' Attempt to retrieve WSUS connection settings for this wrapper from the local machine registry.
    ''' </summary>
    ''' <returns>Success status.</returns>
    ''' <remarks></remarks>
    Private Function GetWSUSServerFromRegistry() As Boolean
        Const sWSUSKey As String = "SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"
        Const sWSUSValue As String = "WUServer"

        'Assume no failure until we know otherwise.
        Dim bFailure As Boolean = False
        Dim oRegKey As RegistryKey = Registry.LocalMachine.OpenSubKey(sWSUSKey)
        Dim oValue As Object

        Try
            'Try getting the WSUS URL
            oValue = oRegKey.GetValue(sWSUSValue)

            'Check it is a REG_SZ
            If oRegKey.GetValueKind(sWSUSValue) = RegistryValueKind.String Then

                'Cast the value to a URI
                Try
                    Dim oURI As New Uri(oValue.ToString)

                    'If this URL uses a valid scheme for WSUS
                    If oURI.Scheme = Uri.UriSchemeHttp Or oURI.Scheme = Uri.UriSchemeHttps Then

                        'Determine whether SSL is used
                        If oURI.Scheme = Uri.UriSchemeHttps Then
                            bSSL = True
                        Else
                            bSSL = False
                        End If

                        'Get hostname and port details
                        sHostName = oURI.Host
                        iPortNumber = oURI.Port

                    Else
                        bFailure = True
                    End If

                Catch ex As Exception
                    bFailure = True
                End Try

                'Return success status
                Return Not bFailure

            End If

        Catch ex As Exception
            bFailure = True
        End Try

        Return Not bFailure

    End Function

    ''' <summary>
    ''' Sets up a DirectorySearcher object for use in checking WSUS computers against the AD.
    ''' </summary>
    ''' <param name="sLDAPConnection">LDAP connection string of AD to search.</param>
    ''' <returns>Initialised DirectorySearcher object if a valid container was specified, otherwise Nothing.</returns>
    ''' <remarks></remarks>
    Private Function GetDirectorySearcher(ByVal sLDAPConnection As String) As DirectoryServices.DirectorySearcher

        Dim oReturn As DirectoryServices.DirectorySearcher = Nothing

        Try
            'Attempt to connect to the specified LDAP path/
            Dim oRoot As New DirectoryServices.DirectoryEntry(sLDAPConnection)

            'If the path has children, initialise a directory search here.
            If Not oRoot.Children Is Nothing Then
                Dim oDomain As New DirectoryServices.DirectorySearcher(oRoot)
                oDomain.PropertiesToLoad.AddRange({"cn", "description", "userAccountControl", "operatingSystem"})
                oReturn = oDomain
            Else
                oReturn = Nothing
            End If
        Catch ex As Exception
            oReturn = Nothing
        End Try

        Return oReturn

    End Function

    ''' <summary>
    ''' Sorts a computer target collection into a list of computer targets sorted by name.
    ''' </summary>
    ''' <param name="ComputerTargets">Computer target collection to sort.</param>
    ''' <returns></returns>
    ''' <remarks></remarks>
    Public Function SortedComputerTargets(ByVal ComputerTargets As ComputerTargetCollection) As List(Of IComputerTarget)

        'Copy the computer targets into a list
        Dim oList As New List(Of IComputerTarget)
        For Each oComputer As IComputerTarget In ComputerTargets
            oList.Add(oComputer)
        Next

        'Now sort the list
        Dim oComparer = New ComputerTargetSort()
        oList.Sort(oComparer)

        Return oList

    End Function


    ''' <summary>
    ''' Checks a list of computers from WSUS against the AD.
    ''' </summary>
    ''' <remarks></remarks>
    Public Function CheckAgainstAD(ByVal oComputers As ComputerTargetCollection) As Dictionary(Of IComputerTarget, ComputerAccountDetail)

        Try
            Dim oResults As New Dictionary(Of IComputerTarget, ComputerAccountDetail)

            'Reset the progress tracking
            StartProgress(oComputers.Count)

            If oComputers.Count > 0 Then

                LogThis("Starting check against Active Directory.")

                'Connect to AD
                Dim oDomain As DirectoryServices.DirectorySearcher = GetDirectorySearcher("LDAP://" & sDomRoot)
                If oDomain Is Nothing Then
                    LogThis("Invalid LDAP connection string specified.")
                Else

                    Dim iScan As Integer = 0
                    Dim oComp As IComputerTarget
                    Dim oDetails As ComputerAccountDetail

                    Do
                        oComp = oComputers(iScan)

                        'Get details of this computer and add it to the results list
                        oDetails = New ComputerAccountDetail(oComp.FullDomainName, oDomain)
                        oResults.Add(oComp, oDetails)

                        'Increment loop counter
                        iScan += 1

                    Loop While PerformStepOK() And (iScan < oComputers.Count)

                    LogThis("Found and checked " & oResults.Count & " computer" & IIf(oResults.Count > 1, "s.", ".") & IIf(bStop, " (Scan stopped early, more may be present.)", ""))

                End If

                'Set progress back to zero
                EndProgress()

            End If

            Return oResults

        Catch ex As Exception
            LogThis("Could not check AD: " & ex.Message)
            Return (Nothing)
        End Try

    End Function

    ''' <summary>
    ''' Check to see if there are any computers in AD that are not in the specified list of computers in WSUS
    ''' </summary>
    ''' <param name="oReported">List of computers known to have checked in to WSUS.</param>
    ''' <returns></returns>
    ''' <remarks></remarks>
    Public Function GetUnreportedComputersFromAD(ByVal oReported As Dictionary(Of IComputerTarget, ComputerAccountDetail)) As List(Of ComputerAccountDetail)

        Try
            Dim sFQDN As String
            Dim oAllFQDNs As New List(Of String)
            Dim oAllDomainComps As DirectoryServices.SearchResultCollection
            Dim oUnreported As New List(Of ComputerAccountDetail)

            LogThis("Checking for additional computers that have not reported to WSUS...")

            'Make a lowercase list of all the FQDNs that were found to have reported in to WSUS
            For Each oAccount As ComputerAccountDetail In oReported.Values
                oAllFQDNs.Add(oAccount.ComputerFQDN.ToLower)
            Next

            'Connect to AD
            Dim oDomain As DirectoryServices.DirectorySearcher = GetDirectorySearcher("LDAP://" & sDomRoot)
            If oDomain Is Nothing Then
                LogThis("Invalid LDAP connection string specified.")
            Else

                'Set the Ad search filter to find all computer accounts
                oDomain.Filter = "(objectClass=computer)"
                'Make sure we load the DNS host name for each result
                oDomain.PropertiesToLoad.Add("dNSHostName")

                'No way to get progress of this search so just make sure the progress bar is empty when we start.
                EndProgress()
                oAllDomainComps = oDomain.FindAll()

                'Now we know how many accounts we need to check
                StartProgress(oAllDomainComps.Count)

                'Get details of all found computers in AD and see if they have reported in to WSUS
                Dim iScan As Integer = 0
                Dim oComp As DirectoryServices.SearchResult
                Do
                    oComp = oAllDomainComps(iScan)

                    'Proceed only if the found computer account has a FQDN
                    If oComp.Properties("dNSHostName").Count = 1 Then
                        sFQDN = oComp.Properties("dNSHostName").Item(0).ToString.ToLower

                        'If the FQDN of this computer account is not in the list of WSUS computers, add it as an unreported computer
                        If Not oAllFQDNs.Contains(sFQDN) Then
                            oUnreported.Add(New ComputerAccountDetail(sFQDN, oDomain))
                        End If
                    End If

                    iScan += 1
                Loop While PerformStepOK() And (iScan < oAllDomainComps.Count)

                LogThis("Found " & oUnreported.Count & " computer" & IIf(oUnreported.Count > 1, "s that have", " that has") & " never reported to WSUS." & IIf(bStop, " (Scan stopped early, more may be present.)", ""))

                'Set progress back to zero
                EndProgress()

            End If

            'Return the list of unreported computers
            Return oUnreported

        Catch ex As Exception
            LogThis("Problem checking for unreported computers: " & ex.Message)
            Return (Nothing)
        End Try

    End Function


    ''' <summary>
    ''' Copy all update approvals from one group to another.
    ''' </summary>
    ''' <param name="oSource">Group from which to copy approvals.</param>
    ''' <param name="oTarget">Group to copy approvals to.</param>
    ''' <returns></returns>
    ''' <remarks></remarks>
    Public Function CopyApprovals(ByVal oSource As IComputerTargetGroup, ByVal oTarget As IComputerTargetGroup) As Boolean

        Dim bResult As Boolean = False

        'Check that valid groups have been passed.
        If (Not oSource Is Nothing) And (Not oTarget Is Nothing) Then

            LogThis("Copying approvals from '" & oSource.Name & "' to '" & oTarget.Name & "'...")

            Try

                'Search for updates approved for the selected source group
                Dim oScope As New UpdateScope
                oScope.ApprovedComputerTargetGroups.Add(oSource)

                'Get approvals for all updates approved for this group
                Dim oApprovals As UpdateApprovalCollection = Server.GetUpdateApprovals(oScope)

                'Set up progress tracking
                StartProgress(oApprovals.Count)

                Dim iApproved As Integer = 0
                Dim iAttempted As Integer = 0

                Dim oUpdate As IUpdate

                'Init loop variables
                Dim iScan As Integer = 0
                Dim oApproval As IUpdateApproval

                If oApprovals.Count > 0 Then
                    Do
                        oApproval = oApprovals(iScan)
                        Try
                            'Get the update information
                            oUpdate = oApproval.GetUpdate
                            Try
                                'If this is an approval for the selected source group, attempt to approve it for the new group, otherwise skip it
                                If oApproval.ComputerTargetGroupId = oSource.Id Then
                                    iAttempted += 1
                                    oUpdate.Approve(oApproval.Action, oTarget, oApproval.Deadline)
                                    iApproved += 1
                                End If
                            Catch ex As Exception
                                LogThis("Could not approve update " & oUpdate.Description & ": " & ex.Message)
                            End Try
                        Catch ex As Exception
                            LogThis("Failed to retrieve information for update ID " & oApproval.UpdateId.ToString & ": " & ex.Message)
                        End Try

                        'Increment loop counter
                        iScan += 1

                    Loop While PerformStepOK() And iScan < oApprovals.Count

                End If

                'Reset progress bar
                EndProgress()

                'Report successful additions.
                LogThis("Copied " & iApproved & "/" & iAttempted & IIf(iAttempted = 1, " approval", " approvals") & " to group '" & oTarget.Name & "'")

            Catch ex As Exception
                LogThis("Could not copy approvals: " & ex.Message)
            End Try

        End If

        Return bResult

    End Function

    ''' <summary>
    ''' Remove one or more computers from WSUS.
    ''' </summary>
    ''' <param name="oComputers">List of computers to remove.</param>
    ''' <returns>Collection of computers that were successfully removed.</returns>
    ''' <remarks></remarks>
    Public Function RemoveFromWSUS(ByVal oComputers As ComputerTargetCollection) As ComputerTargetCollection

        'Build result list
        Dim oResult As New ComputerTargetCollection

        'Reset progress bar
        StartProgress(oComputers.Count)

        'Init loop variables
        Dim oRemove As IComputerTarget
        Dim sRemove As String
        Dim iScan As Integer = 0

        Do
            oRemove = oComputers(iScan)
            sRemove = oRemove.FullDomainName

            'Attempt to remove the computer from WSUS
            Try
                oRemove.Delete()
                oResult.Add(oRemove)

                'Log successful removal
                LogThis("Removed " & sRemove & " from WSUS.")

            Catch ex As Exception
                'Log an error message if it fails (no documentation on what Exceptions can be thrown here)
                Dim sError As String = "Unable to remove " & sRemove & ": " & ex.Message
                LogThis(sError)
            End Try

            'Increment loop counter
            iScan += 1

        Loop While PerformStepOK() And iScan < oComputers.Count


        'If computers were successfully removed, log how many
        If oResult.Count > 0 Then
            LogThis("Successfully removed " & oResult.Count & "/" & oComputers.Count & IIf(oComputers.Count > 1, " computers", " computer") & " from WSUS.")
        End If

        'Set progress bar back to zero
        EndProgress()

        Return oResult

    End Function

    ''' <summary>
    ''' Remove a collection of computers from a target group.
    ''' </summary>
    ''' <param name="oComputers">Computers to remove.</param>
    ''' <param name="oGroup">Group to remove computers from.</param>
    ''' <returns>Number of computers successfuly removed from the group.</returns>
    ''' <remarks></remarks>
    Public Function RemoveFromGroup(ByVal oComputers As ComputerTargetCollection, ByVal oGroup As IComputerTargetGroup) As Integer

        'Initialise progress bar
        StartProgress(oComputers.Count)

        'Keep track of successful removals.
        Dim iRemoved As Integer = 0

        'Init loop variables
        Dim oComputer As IComputerTarget
        Dim iScan As Integer = 0

        'Enumerate each selected computer and remove it from the selected group
        Do
            oComputer = oComputers(iScan)
            Try
                oGroup.RemoveComputerTarget(oComputer)
                iRemoved += 1
            Catch ex As Exception
                LogThis("Could not remove " & oComputer.FullDomainName & " from group '" & oGroup.Name & "': " & ex.Message)
            End Try

            'Increment loop counter
            iScan += 1

        Loop While PerformStepOK() And iScan < oComputers.Count

        'Report successful removals.
        LogThis("Removed " & iRemoved & "/" & oComputers.Count & IIf(oComputers.Count = 1, " computer", " computers") & " from group '" & oGroup.Name & "'")

        'Reset progress bar
        EndProgress()

        Return iRemoved

    End Function

    Public Function AddToGroup(ByVal oComputers As ComputerTargetCollection, ByVal oGroup As IComputerTargetGroup) As Integer

        'Set up progress tracking
        StartProgress(oComputers.Count)

        'Track how many computers were added to the group
        Dim iAdded As Integer = 0

        'Init loop variables
        Dim oComputer As IComputerTarget
        Dim iScan As Integer = 0

        'Enumerate each selected computer and remove it from the selected group
        Do
            oComputer = oComputers(iScan)

            Try
                oGroup.AddComputerTarget(oComputer)
                iAdded += 1
            Catch ex As Exception
                LogThis("Could not add " & oComputer.FullDomainName & " to group '" & oGroup.Name & "': " & ex.Message)
            End Try

            'Increment loop counter
            iScan += 1

        Loop While PerformStepOK() And iScan < oComputers.Count

        'Reset progress bar
        EndProgress()

        'Report successful additions.
        LogThis("Added " & iAdded & "/" & oComputers.Count & IIf(oComputers.Count = 1, " computer", " computers") & " to group '" & oGroup.Name & "'")

    End Function

    ''' <summary>
    ''' Searches for updates containing the specified string within the Title, Description, KB article number, MSRC severity, or bulletin number
    ''' </summary>
    ''' <param name="sSearchFor">Text to search for.</param>
    ''' <param name="bSuperceded">(Optional) Whether to include superceded or expired updates in the results. Defaults to False.</param>
    ''' <returns>List of updates matching the specified parameters.</returns>
    ''' <remarks></remarks>
    Public Function SearchUpdates(ByVal sSearchFor As String, Optional ByVal bSuperceded As Boolean = False) As UpdateCollection

        Dim oUpdates As UpdateCollection = Nothing

        Try
            'Get matching updates from WSUS
            oUpdates = Server.SearchUpdates(sSearchFor)

            'Unless we want to also search superceded updates, strip them from the results.
            If Not bSuperceded Then

                Dim iUpdateCheck As Integer = 0
                'Check each update and remove it if neccesary.
                While iUpdateCheck < oUpdates.Count
                    If oUpdates(iUpdateCheck).IsSuperseded Or oUpdates(iUpdateCheck).PublicationState = PublicationState.Expired Or oUpdates(iUpdateCheck).IsLatestRevision = False Then
                        oUpdates.RemoveAt(iUpdateCheck)
                    Else
                        iUpdateCheck += 1
                    End If
                End While

            End If

            'Log how many matching updates were found
            LogThis("Found " & IIf(oUpdates.Count = 1, "1 update.", oUpdates.Count & " updates."))

        Catch ex As Exception
            LogThis("Could not check compliance: " & ex.Message)
        End Try

        Return oUpdates

    End Function

    ''' <summary>
    ''' Checks the overall install compliance of a computer group against a collection of updates.
    ''' </summary>
    ''' <param name="oUpdates">Updates to check state of.</param>
    ''' <param name="oGroup">Computer group to check.</param>
    ''' <param name="oCompliance">Variable to store detailed compliance data.</param>
    ''' <returns>Success status.</returns>
    ''' <remarks></remarks>
    Public Function GetUpdateCompliance(ByVal oUpdates As UpdateCollection, ByVal oGroup As IComputerTargetGroup, ByVal oCompliance As Dictionary(Of String, WSUSWrapper.UpdateCompliance)) As Boolean

        Dim bResult As Boolean = False

        Dim iUpdateScan As Integer = 0
        Dim oUpdate As IUpdate
        Dim iInstallScan As Integer
        Dim oInstall As IUpdateInstallationInfo

        Try

            Dim iComputers As Integer = oGroup.GetComputerTargets.Count

            'Start tracking progress.
            StartProgress(iComputers * oUpdates.Count)

            'Log how many computers and updates are being checked.
            LogThis("Checking " & IIf(iComputers = 1, "1 computer", iComputers & " computers") & " for compliance with " & IIf(oUpdates.Count = 1, "1 update.", oUpdates.Count & " updates."))

            'Enumerate each update
            Do
                'Get current update
                oUpdate = oUpdates(iUpdateScan)

                'Get install status for the current update for each computer in the selected group
                Dim oInstalls As UpdateInstallationInfoCollection = oGroup.GetUpdateInstallationInfoPerComputerTarget(oUpdate)

                iInstallScan = 0

                'Populate update status for each computer row
                Do
                    oInstall = oInstalls(iInstallScan)
                    'Log this update's installation state against the computer's overall compliance
                    oCompliance.Item(oInstall.ComputerTargetId).Items.Add(oUpdate, oInstall.UpdateInstallationState)

                    'Next install info
                    iInstallScan += 1

                Loop While (iInstallScan < oInstalls.Count) And PerformStepOK()

                'Next update
                iUpdateScan += 1

            Loop While (iUpdateScan < oUpdates.Count) And PerformStepOK()

            LogThis("Compliance check complete.")
            bResult = True

        Catch ex As Exception
            LogThis("Could not check compliance: " & ex.Message)
        End Try

        'Finish tracking progress
        EndProgress()

        'Return success status
        Return bResult

    End Function


    ''' <summary>
    ''' IComparer to sort computer targets by hostname.
    ''' </summary>
    ''' <remarks></remarks>
    Private Class ComputerTargetSort
        Implements IComparer(Of IComputerTarget)

        Public Function Compare(ByVal x As IComputerTarget, ByVal y As IComputerTarget) As Integer Implements IComparer(Of IComputerTarget).Compare
            Return New CaseInsensitiveComparer().Compare(x.FullDomainName, y.FullDomainName)
        End Function

    End Class

    ''' <summary>
    ''' IComparer to sort computer account details by FQDN.
    ''' </summary>
    ''' <remarks></remarks>
    Public Class ComputerAccountDetailSort
        Implements IComparer(Of ComputerAccountDetail)

        Public Function Compare(ByVal x As ComputerAccountDetail, ByVal y As ComputerAccountDetail) As Integer Implements IComparer(Of ComputerAccountDetail).Compare
            Return New CaseInsensitiveComparer().Compare(x.ComputerFQDN, y.ComputerFQDN)
        End Function

    End Class


    Public Class ComputerAccountDetail

        'Internal property storage
        Private bInAD As Boolean
        Private bEnabled As Boolean
        Private sDesc As String
        Private sFQDN As String
        Private sOS As String

        ''' <summary>
        ''' Flag to determine whether a search has been attempted for this object
        ''' </summary>
        ''' <remarks></remarks>
        Private bSearched As Boolean

        ''' <summary>
        ''' DirectorySearcher object to use to find the computer.
        ''' </summary>
        ''' <remarks></remarks>
        Public ADSearcher As DirectoryServices.DirectorySearcher

        ''' <summary>
        ''' Full domain name of computer to this object stores details for.
        ''' </summary>
        ''' <remarks></remarks>
        Public ReadOnly Property ComputerFQDN As String
            Get
                Return sFQDN
            End Get
        End Property

        ''' <summary>
        ''' Whether the computer exists in the Active Directory.
        ''' </summary>
        ''' <value></value>
        ''' <returns></returns>
        ''' <remarks></remarks>
        Public ReadOnly Property InAD As Boolean
            Get
                CheckSearched()
                Return bInAD
            End Get
        End Property

        ''' <summary>
        ''' Whether the computer has an enabled account in Active Directory
        ''' </summary>
        ''' <value></value>
        ''' <returns></returns>
        ''' <remarks></remarks>
        Public ReadOnly Property Enabled As Boolean
            Get
                CheckSearched()
                'If computer is not in AD it can never be enabled, so return false
                If Not bInAD Then
                    Return False
                Else
                    'Otherwise return actual status
                    Return bEnabled
                End If
            End Get
        End Property

        ''' <summary>
        ''' The description of the computer in Active Directory
        ''' </summary>
        ''' <value></value>
        ''' <returns></returns>
        ''' <remarks></remarks>
        Public ReadOnly Property Description As String
            Get
                CheckSearched()
                'If computer is not in AD it cannot have a description, so return an empty string
                If Not bInAD Then
                    Return ""
                Else
                    'Otherwise return actual description
                    Return sDesc
                End If
            End Get
        End Property

        ''' <summary>
        ''' The operating system of the computer in Active Directory
        ''' </summary>
        ''' <value></value>
        ''' <returns></returns>
        ''' <remarks></remarks>
        Public ReadOnly Property OS As String
            Get
                CheckSearched()
                'If computer is not in AD it cannot have an OS value, so return an empty string
                If Not bInAD Then
                    Return ""
                Else
                    'Otherwise return actual operating system value
                    Return sOS
                End If
            End Get
        End Property

        ''' <summary>
        ''' A summary string of the computer's status in Active Directory
        ''' </summary>
        ''' <value></value>
        ''' <returns></returns>
        ''' <remarks></remarks>
        Public ReadOnly Property Status As String
            Get
                CheckSearched()
                If Not bInAD Then
                    Return "(Not found)"
                Else
                    If bEnabled Then
                        Return "Enabled"
                    Else
                        Return "Disabled"
                    End If
                End If
            End Get
        End Property

        ''' <summary>
        ''' Creates a new computer account details object.
        ''' </summary>
        ''' <remarks></remarks>
        Public Sub New()
            bInAD = False
            bEnabled = False
            sDesc = ""
            bSearched = False
            sFQDN = ""
        End Sub

        ''' <summary>
        ''' Creates a new computer account details object.
        ''' </summary>
        ''' <param name="sFullDomainName">Full domain name of the computer to find in AD.</param>
        ''' <param name="oSearcher">Details of the AD to search the computer for.</param>
        ''' <remarks></remarks>
        Public Sub New(ByVal sFullDomainName As String, ByVal oSearcher As DirectoryServices.DirectorySearcher)
            Me.New()

            'Set search properties
            sFQDN = sFullDomainName

            ADSearcher = oSearcher

            'Check the AD for this account now
            FindComputerDetails()
        End Sub

        ''' <summary>
        ''' Check if a search has already been performed for this computer, and if not, perform a search immediately.
        ''' </summary>
        ''' <remarks></remarks>
        Private Sub CheckSearched()
            'Check the AD now if we haven't already done so.
            If Not bSearched Then FindComputerDetails()
        End Sub

        ''' <summary>
        ''' Attempt to find the computer in AD and retrieve details
        ''' </summary>
        ''' <remarks></remarks>
        Private Sub FindComputerDetails()

            If (Not sFQDN = "") And (Not ADSearcher Is Nothing) Then

                bSearched = True

                'Identifies a disabled account in AD
                Const ADS_UF_ACCOUNTDISABLE As Integer = 2

                Dim sCN As String

                'Isolate hostname
                sCN = sFQDN.Split(".")(0)

                'Set the Ad search filter to find this computer's CN and hostname
                ADSearcher.Filter = String.Format("(&(objectClass=computer)(cn={0})(dNSHostName={1}))", {sCN, sFQDN})

                'Attempt to find the computer in AD
                Dim oFound As DirectoryServices.SearchResult = ADSearcher.FindOne
                If oFound Is Nothing Then
                    bInAD = False
                Else
                    bInAD = True

                    'Check if the account is enabled or disabled in AD
                    If oFound.Properties("userAccountControl").Count > 0 Then
                        'See if it is disabled.
                        bEnabled = (oFound.Properties("userAccountControl").Item(0) And ADS_UF_ACCOUNTDISABLE)
                        'Now invert the result to specify whether the account is enabled.
                        bEnabled = Not bEnabled
                    Else
                        bEnabled = True
                    End If

                    'Get the description of the computer account if it has one.
                    If oFound.Properties("description").Count = 0 Then
                        sDesc = ""
                    Else
                        sDesc = oFound.Properties("description").Item(0)
                    End If

                    'Get the OS of the computer account if it has one.
                    If oFound.Properties("operatingSystem").Count = 0 Then
                        sOS = ""
                    Else
                        sOS = oFound.Properties("operatingSystem").Item(0)
                    End If

                End If

            End If

        End Sub

    End Class


    ''' <summary>
    ''' Track the overall compliance of a set of update installation states.
    ''' </summary>
    ''' <remarks></remarks>
    Public Class UpdateCompliance

        ''' <summary>
        ''' Details of each update installation being tracked.
        ''' </summary>
        ''' <remarks></remarks>
        Public Items As Dictionary(Of IUpdate, UpdateInstallationState)

        ''' <summary>
        ''' Create a new compliance tracker.
        ''' </summary>
        ''' <remarks></remarks>
        Sub New()
            Items = New Dictionary(Of IUpdate, UpdateInstallationState)
        End Sub

        ''' <summary>
        ''' Retrieve the rank of a given update installation state according to how desirable it is.
        ''' </summary>
        ''' <param name="oState">Installation state to evaluate.</param>
        ''' <returns></returns>
        ''' <remarks>Not Applicable is a special state in terms of rank, so is given a rank of -1 which should be ignored.</remarks>
        Private Function StateRank(ByRef oState As UpdateInstallationState) As Integer
            Dim iRank As Integer

            Select Case oState
                Case UpdateInstallationState.Installed
                    iRank = 5
                Case UpdateInstallationState.InstalledPendingReboot
                    iRank = 4
                Case UpdateInstallationState.Downloaded
                    iRank = 3
                Case UpdateInstallationState.Unknown
                    iRank = 2
                Case UpdateInstallationState.Failed
                    iRank = 1
                Case UpdateInstallationState.NotInstalled
                    iRank = 0
                Case UpdateInstallationState.NotInstalled
                    iRank = -1
                Case Else
                    iRank = oState
            End Select

            Return iRank

        End Function

        ''' <summary>
        ''' Returns the best installation state of this set of states.
        ''' </summary>
        ''' <returns></returns>
        ''' <remarks></remarks>
        Private Function BestState() As UpdateInstallationState

            Dim eBestState As UpdateInstallationState = -1
            Dim bNA As Boolean = True

            'Check each recorded state
            For Each eState As UpdateInstallationState In Items.Values

                'If something other than Not Applicable, determine whether this is the new lowest state.
                If Not (eState = UpdateInstallationState.NotApplicable) Then
                    bNA = False
                    If Not bNA Then If StateRank(eState) >= StateRank(eBestState) Then eBestState = eState
                End If
            Next

            'If all states are Not Applicable, that is the best state.
            If bNA Then eBestState = UpdateInstallationState.NotApplicable

            Return eBestState
        End Function

        ''' <summary>
        ''' Returns the overall update compliance state of this set of states.
        ''' </summary>
        ''' <returns></returns>
        ''' <remarks>Normally this would be the worst state of the set, or Not Applicable if none of the updates apply.</remarks>
        Private Function WorstState() As UpdateInstallationState

            Dim eWorstState As UpdateInstallationState = 7
            Dim bNA As Boolean = True

            'Check each recorded state
            For Each eState As UpdateInstallationState In Items.Values

                'If something other than Not Applicable, determine whether this is the new lowest state.
                If Not (eState = UpdateInstallationState.NotApplicable) Then
                    bNA = False
                    If StateRank(eState) <= StateRank(eWorstState) Then eWorstState = eState
                End If

            Next

            'If all states are Not Applicable, that is the worst state.
            If bNA Then eWorstState = UpdateInstallationState.NotApplicable

            Return eWorstState
        End Function

        ''' <summary>
        ''' Get the human-readable string representation of an installation state.
        ''' </summary>
        ''' <param name="oState">State to get description of.</param>
        ''' <returns>Human-readable string representation of the given state.</returns>
        ''' <remarks></remarks>
        Private Function StateDescription(ByVal oState As UpdateInstallationState) As String
            Dim sDesc As String

            Select Case oState
                Case UpdateInstallationState.Installed
                    sDesc = "Installed"
                Case UpdateInstallationState.InstalledPendingReboot
                    sDesc = "Installed, Pending Reboot"
                Case UpdateInstallationState.Downloaded
                    sDesc = "Downloaded"
                Case UpdateInstallationState.Unknown
                    sDesc = "Unknown"
                Case UpdateInstallationState.Failed
                    sDesc = "Failed"
                Case UpdateInstallationState.NotApplicable
                    sDesc = "Not Applicable"
                Case Else 'NotInstalled
                    sDesc = "Not Installed"
            End Select

            Return sDesc
        End Function

        ''' <summary>
        ''' Text description of the install state of a given update in this set.
        ''' </summary>
        ''' <param name="oUpdate">Update to get install state for.</param>
        ''' <returns></returns>
        ''' <remarks></remarks>
        Public Function ItemStateDescription(ByVal oUpdate As IUpdate) As String
            Return StateDescription(Items(oUpdate))
        End Function

        ''' <summary>
        ''' Text description of the best install state in the set.
        ''' </summary>
        ''' <returns></returns>
        ''' <remarks></remarks>
        Public Function BestStateDescription() As String
            Return StateDescription(BestState)
        End Function

        ''' <summary>
        ''' Text description of the worst install state in the set.
        ''' </summary>
        ''' <returns></returns>
        ''' <remarks></remarks>
        Public Function WorstStateDescription() As String
            Return StateDescription(WorstState)
        End Function

    End Class

End Class
